iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Vibe Coding

夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略系列 第 25

Day25|悲觀一點反而比較安全?從悲觀鎖看懂最後一個名額怎麼守

  • 分享至 

  • xImage
  •  

昨天講完 Optimistic Locking(樂觀鎖),今天就來看看它的好夥伴:

Pessimistic Locking(悲觀鎖)。

光看名字,就知道是兩位個性完全不同的朋友對吧(笑)。

樂觀派會想:

「應該不會那麼剛好撞在一起啦,真的撞到再處理!」

那悲觀派呢?

今天就來看看它到底有多「悲觀」,以及 BuJo 為什麼會在報名流程裡選擇這種做法。


Pessimistic Locking(悲觀鎖)到底在悲觀什麼?

Pessimistic Locking(悲觀鎖)的想法真的很符合它的名字:

先假設這裡很可能發生衝突,所以先把需要保護的資料門鎖上,讓會互相衝突的操作先等等,再往下處理。

也就是:

開始處理
↓
先把這筆資料的門鎖上
↓
讀取最新資料
↓
判斷能不能繼續
↓
可以 → 寫入
不行 → 拒絕
↓
結束並解鎖讓其他人進入

如果另一個 Request 同時也想取得同一筆互相衝突的鎖,它不會拿著舊資料繼續往下跑。

而是:

先等等。

等前一個 Transaction 完成、鎖被釋放之後,再輪到自己取得鎖、重新讀取最新資料,接著做判斷。

這次 BuJo 剛好就有一個很適合悲觀派出場的情境:

搶最後一個名額。


回到 BuJo:9 個人,卻可能報名成 11 個?

假設一場 Activity 的人數上限是:

participant_target = 10

現在已經有 9 位參與者。

這時 Request A 和 Request B 幾乎同時按下報名:

Request A                  Request B
    │                          │
    ├─ 讀到目前 9 人            ├─ 也讀到目前 9 人
    │                          │
    ├─ 判斷:還有名額            ├─ 判斷:也還有名額
    │                          │
    ├─ 建立第 10 筆報名          └─ 建立第 11 筆報名

問題就出現了。

原本的規則明明是:

最多 10 人。

但每一個 Request 單獨看,人數檢查其實都沒有寫錯。

它們都真的看到了:

目前人數 = 9
9 < 10
→ 可以報名

真正的問題,出在:

「檢查人數」和「建立報名」中間那段時間。

所以 BuJo 這次不能只讓兩個 Request 各自讀完再往下做,而是需要讓同一場 Activity 的報名依序通過。

這就是 SELECT ... FOR UPDATE 要出場的地方。


SELECT ... FOR UPDATE 到底多做了什麼?

在 BuJo 的 joinActivity() 裡,可以看到這段:

await tx.$queryRaw`
  SELECT id
  FROM activities
  WHERE id = ${id}
  FOR UPDATE
`;

原本:

SELECT

是在讀取資料。

加上:

FOR UPDATE

之後,這次查詢除了讀取指定資料,也會對符合條件的 Row(資料列)取得 Row-Level Lock(資料列層級鎖定)。

這裡指定的是:

WHERE id = ${id}

所以 BuJo 鎖住的是:

這一場 Activity 對應的資料列。

不是把整張 activities Table(資料表)全部鎖起來。

也就是說,不同 Activity 的報名通常還是可以各自進行;真正需要等待的,是同一筆資料上互相衝突的鎖定操作。

那把這筆資料的門鎖上之後,BuJo 接下來做了什麼呢?

我們直接進 Code 裡看最清楚。


BuJo 怎麼把「先鎖、再判斷」寫進 Code?

下面只保留和這次「避免活動人數超收」直接相關的部分:

// 把鎖定、重新讀取、檢查與寫入
// 放在同一個 Transaction(交易)裡
const outcome = await prisma.$transaction(async (tx) => {
  // 先取得指定 Activity Row 的 Row-Level Lock
  // 同一場 Activity 上衝突的 FOR UPDATE 會先等待
  await tx.$queryRaw`
    SELECT id
    FROM activities
    WHERE id = ${id}
    FOR UPDATE
  `;

  // 把這筆資料的門鎖上之後,才重新讀取最新資料
  const activity = await tx.activity.findUnique({
    where: { id },
    include: {
      participants: {
        where: { status: "joined" },
      },
      schedule: true,
      candidateSlots: {
        include: { availabilities: true },
      },
    },
  });

  const currentCount = activity.participants.length;

  // 用最新人數判斷是否額滿
  if (
    activity.participant_target &&
    currentCount >= activity.participant_target
  ) {
    return {
      status: 400,
      message: req.t("activity.full"),
    };
  }

  // 還有名額,才建立這筆報名
  await tx.activityParticipant.create({
    data: {
      activity_id: id,
      user_id: userId,
    },
  });

  // 真實 Code 後面還會繼續處理時段與站內通知
  return null;
});

這段最重要的是整個順序:

先把這筆資料的門鎖上
↓
重新讀取最新資料
↓
計算 currentCount
↓
檢查有沒有額滿
↓
還有名額才 create()

所以前面的 SELECT ... FOR UPDATE 主要負責把這筆資料保護起來。

真正拿來判斷人數的 Activity 和 Participants,則是在取得鎖之後才重新讀取。


兩個 Request 同時抵達時,真的會怎麼走?

還是回到:

人數上限:10
目前人數:9

這次有了 Row-Level Lock(資料列層級鎖定)之後:

執行順序 Request A Request B
1 取得 Activity 的 Row-Level Lock 嘗試取得同一筆鎖
2 繼續執行 等待
3 重新讀取,看到目前 9 人 等待
4 建立第 10 筆報名 等待
5 Transaction 提交、釋放鎖 取得鎖
6 已完成 重新讀取,看到目前 10 人
7 已完成 判斷額滿,不建立第 11 筆

所以 Request B 等輪到自己之後,不會繼續拿原本的 9 人來判斷,而是重新讀到最新的 10 人:

現在已經 10 人
→ 額滿
→ 不建立報名

這樣原本的規則:

活動人數不能超過 participant_target

即使在兩個 Request 幾乎同時抵達的情況下,還是守得住。


這把鎖,要一路鎖到什麼時候?

看到這裡,還有一個很重要的問題:

這把 Row-Level Lock(資料列層級鎖定),到底會鎖到什麼時候?

BuJo 的 SELECT ... FOR UPDATE 不是執行完那一行就立刻解鎖。

它會一路維持到目前這個 Transaction 結束:

先把這筆資料的門鎖上
↓
重新讀取最新資料
↓
檢查是否額滿
↓
建立報名
↓
Transaction 結束
↓
釋放鎖

這也是為什麼 FOR UPDATE 不能只單獨出現一下。

如果鎖完馬上結束 Transaction,再跑去外面檢查人數、建立報名,中間就又會留下其他 Request 可以插進來的空隙。

所以這裡真正要想的,不只是:

「哪一行需要加 Lock?」

而是:

「這把 Lock 要一路保護到哪裡,才可以放開?」

在 BuJo 的報名流程裡,答案就是:

一路保護到這次報名需要保持一致的資料庫操作完成。

而這裡的 Transaction,除了讓相關資料庫操作維持在同一個流程裡,也剛好決定了這把 Row-Level Lock 的生命週期。

等 Transaction Commit 或 Rollback 之後,這把鎖才會真正被釋放。


那為什麼 LINE 通知反而放在 Transaction 外面?

BuJo 報名後如果剛好達到成團人數,也可能需要發送 LINE 外部通知。

但這段沒有一起塞進剛才的 Transaction:

// Transaction 成功提交後
// 才處理 LINE 外部通知
if (formationReadyCreatorId) {
  await sendActivityLifecycleLineNotifications({
    userIds: [formationReadyCreatorId],
    activityId: id,
    type: NOTIFICATION_TYPES.FORMATION_READY,
  });
}

因為 LINE 通知是一個外部 HTTP Request。

如果外部服務回得比較慢,而我們還在 Transaction 裡等它:

剛才取得的 Row-Level Lock 也可能跟著維持更久。

後面想報名同一場 Activity 的 Request,就得一起多等。

另外,外部訊息一旦真的送出去,也不像資料庫寫入一樣,可以因為 Transaction Rollback(交易回滾)就一起收回。

所以 BuJo 在這裡把兩邊分開:

Transaction 裡
├─ 鎖定 Activity
├─ 重新讀取資料
├─ 檢查人數
├─ 建立報名
└─ 處理需要一起保持一致的資料庫寫入

Transaction 成功提交後
└─ 發送 LINE 外部通知

這不是說所有系統的外部呼叫都一定不能放進 Transaction。

而是這裡要特別考慮:

哪些事情真的需要跟著這把鎖一起被保護?

不需要的工作,就沒必要讓鎖陪著一起等。


最後再用 Test 看看:額滿之後真的不會再寫入嗎?

BuJo 也替「活動已經額滿」留下了 Test。

這裡只保留和今天主題直接有關的部分:

// 模擬目前已經達到 participant_target
prisma.activity.findUnique.mockResolvedValue(
  makeActivity({
    status: "recruiting",
    participant_target: 1,
    participants: [makeParticipant(CREATOR_ID)],
  }),
);

await joinActivity(
  makeReq({ userId: PARTICIPANT_ID }),
  res,
);

// 報名流程有先執行鎖定查詢
expect(prisma.$queryRaw).toHaveBeenCalled();

// 額滿後不能再建立新的 Participant
expect(
  prisma.activityParticipant.create,
).not.toHaveBeenCalled();

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
  message: "活動人數已滿",
});

這個 Test 可以確認:

報名流程有執行鎖定查詢
+
目前已額滿
↓
不建立新的 Participant
↓
回傳 400

不過這裡要注意一個邊界。

這個 Test 使用的是 Mock(模擬物件),所以它能驗證的是:

Code 有把鎖定查詢放進報名流程,而且額滿之後不會繼續新增參與者。

它並沒有真的啟動兩個 Transaction 去競爭同一筆 Row-Level Lock。

如果要驗證「第二個 Request 真的會在 PostgreSQL 裡等待」,就需要再搭配連接真實測試資料庫的整合測試。


樂觀派和悲觀派,到底該找誰?

雖然個性差很多,但它們其實沒有誰一定比較好。

比較項目 Optimistic Locking(樂觀鎖) Pessimistic Locking(悲觀鎖)
基本想法 先讓操作進行,真正寫入時再確認 先鎖住資料,讓衝突操作暫時等待
優點 沒有衝突時,不需要先讓其他操作等待 需要根據最新資料做判斷時,可以先把衝突操作排開
代價 發生衝突時,要另外處理失敗、回報或重試 可能增加等待時間,也要控制鎖定範圍與時間
適合情境 衝突較少,而且可以接受其中一次操作最後失敗 容易爭搶有限資源,而且不能讓多人同時通過
BuJo 案例 確認成團時,避免重複狀態轉換與通知 報名最後一個名額時,避免人數超過上限

所以並不是:

悲觀鎖比較安全
→ 永遠用悲觀鎖

也不是:

樂觀鎖不用等待
→ 永遠用樂觀鎖

真正要看的,是這條業務規則能不能接受:

大家先往下做
↓
真的發生衝突
↓
其中一個操作最後失敗

還是它更適合:

先讓衝突操作排隊
↓
輪到自己時
↓
再根據最新資料判斷

BuJo 的確認成團,可以接受其中一個 Request 因為 State 已經被改變,而收到 409 Conflict

但「最後一個名額」真正要守住的是:

不能因為兩個人同時報名,就讓第 11 個人一起進來。

所以兩個情境,最後選了不同的並發控制方式。


悲觀派雖然想得多,但真的有它擔心的道理

如果樂觀鎖像那種:

「先做再說啦!真的撞到了再處理!」

的朋友,

那悲觀鎖就像那種做事情前,會先把可能出問題的地方都想一遍的朋友(笑)。

它看到只剩最後一個名額,不會先假設:

「應該不會剛好兩個人一起來吧。」

而是會先想:

「如果真的同時來了呢?」

所以乾脆先把順序排好,再一個一個處理。

我自己覺得悲觀鎖有趣的地方,是平常同時操作的人不多時,可能很難真的感覺到它帶來的差異。

但如果很多 Request 都在搶同一份有限資源,像是最後一個名額、熱門票券或有限庫存,事情就不一樣了。

這時候:

鎖哪裡、鎖多久、多少 Request 要一起等,可能都會直接影響整個系統的表現。

所以真正要看的,不只是使用者多不多,而是:

「這是不是一筆很多人會同時爭搶,而且不能出錯的資料?」

如果答案是「是」,那即使系統很大,悲觀鎖還是可能很合理。

只是這時候,就更需要控制鎖定的範圍和時間;流量再大時,也可能需要搭配排隊、暫時保留名額等其他機制。

所以現在再看 SELECT ... FOR UPDATE,我已經不會只把它當成一段 SQL。

反而會開始多想一層:

這筆資料真的需要先鎖嗎?如果需要,又該鎖到哪裡才剛剛好?

我覺得這也是悲觀鎖最值得理解的地方。

它不是單純「比較小心」。

而是當很多人同時搶同一份資源時,怎麼小心,本身也會變成系統設計的一部分。

兩個可愛的鎖介紹完了,明天要換另一個讓我腦袋打結的地方了(笑)。

當大家填了一堆「我有空的時間」,BuJo 到底要怎麼把這些重疊的時段算出來?

下一篇,就來拆 候選時段重疊計算演算法


參考資料


上一篇
Day24|先做再說,真的撞到再處理?從樂觀鎖看懂並發寫入
下一篇
Day26|大家都有空,但到底哪個時段最好?從候選時段演算法看懂時間怎麼切、算再合回
系列文
夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言